Español

Una guía completa de genéricos en TypeScript que cubre sintaxis, beneficios y uso avanzado para manejar tipos de datos complejos en el desarrollo de software global.

Genéricos en TypeScript: Dominando Tipos de Datos Complejos para Aplicaciones Robustas

TypeScript, un superconjunto de JavaScript, capacita a los desarrolladores para escribir código más robusto y mantenible a través del tipado estático. Entre sus características más potentes se encuentran los genéricos, que permiten escribir código que puede funcionar con una variedad de tipos de datos manteniendo al mismo tiempo la seguridad de tipos. Esta guía ofrece una exploración exhaustiva de los genéricos de TypeScript, centrándose en su aplicación a tipos de datos complejos en el contexto del desarrollo de software global.

¿Qué son los genéricos?

Los genéricos proporcionan una forma de escribir código reutilizable que puede funcionar con diferentes tipos. En lugar de escribir funciones o clases separadas para cada tipo que desees admitir, puedes escribir una única función o clase que utilice parámetros de tipo. Estos parámetros de tipo son marcadores de posición para los tipos reales que se utilizarán cuando la función o clase sea llamada o instanciada. Esto es especialmente útil al tratar con estructuras de datos complejas donde el tipo de datos dentro de esas estructuras puede variar.

Beneficios de usar genéricos

Sintaxis básica de los genéricos

La sintaxis básica de los genéricos implica el uso de corchetes angulares (< >) para declarar parámetros de tipo. Estos parámetros de tipo suelen nombrarse T, K, V, etc., pero puedes usar cualquier identificador válido. Aquí tienes un ejemplo sencillo de una función genérica:


function identity<T>(arg: T): T {
  return arg;
}

let myString: string = identity<string>("hello");
let myNumber: number = identity<number>(123);
let myBoolean: boolean = identity<boolean>(true);

console.log(myString); // Salida: hello
console.log(myNumber); // Salida: 123
console.log(myBoolean); // Salida: true

En este ejemplo, <T> declara un parámetro de tipo llamado T. La función identity toma un argumento de tipo T y devuelve un valor de tipo T. Al llamar a la función, puedes especificar explícitamente el parámetro de tipo (p. ej., identity<string>) o dejar que TypeScript lo infiera basándose en el tipo del argumento.

Trabajando con tipos de datos complejos

Los genéricos se vuelven particularmente valiosos cuando se trata de tipos de datos complejos como arrays, objetos e interfaces. Exploremos algunos escenarios comunes:

Arrays genéricos

Puedes usar genéricos para crear funciones o clases que trabajen con arrays de diferentes tipos:


function arrayToString<T>(arr: T[]): string {
  return arr.join(", ");
}

let numberArray: number[] = [1, 2, 3, 4, 5];
let stringArray: string[] = ["apple", "banana", "cherry"];

console.log(arrayToString(numberArray)); // Salida: 1, 2, 3, 4, 5
console.log(arrayToString(stringArray)); // Salida: apple, banana, cherry

Aquí, la función arrayToString toma un array de tipo T[] y devuelve una representación en cadena de texto del array. Esta función funciona con arrays de cualquier tipo, lo que la hace altamente reutilizable.

Objetos genéricos

Los genéricos también se pueden usar para definir funciones o clases que trabajen con objetos de diferentes formas:


interface Person {
  name: string;
  age: number;
  country: string; // Se añade el país para el contexto global
}

interface Product {
  id: number;
  name: string;
  price: number;
  currency: string; // Se añade la moneda para el contexto global
}

function displayInfo<T extends { name: string }>(item: T): void {
  console.log(`Name: ${item.name}`);
}

let person: Person = { name: "Alice", age: 30, country: "USA" };
let product: Product = { id: 1, name: "Laptop", price: 1200, currency: "USD" };

displayInfo(person); // Salida: Name: Alice
displayInfo(product); // Salida: Name: Laptop

En este ejemplo, la función displayInfo toma un objeto de tipo T que debe tener una propiedad name de tipo string. La cláusula extends { name: string } es una restricción, que especifica los requisitos mínimos para el parámetro de tipo T. Esto asegura que la función pueda acceder de forma segura a la propiedad name.

Uso avanzado de genéricos

Los genéricos de TypeScript ofrecen características más avanzadas que te permiten crear código aún más flexible y potente. Exploremos algunas de estas características:

Múltiples parámetros de tipo

Puedes definir funciones o clases con múltiples parámetros de tipo:


function merge<T, U>(obj1: T, obj2: U): T & U {
  return { ...obj1, ...obj2 };
}

interface Name {
  firstName: string;
}

interface Age {
  age: number;
}

const person: Name = { firstName: "Bob" };
const details: Age = { age: 42 };

const merged = merge(person, details);
console.log(merged.firstName); // Salida: Bob
console.log(merged.age); // Salida: 42

La función merge toma dos objetos de tipos T y U y devuelve un nuevo objeto que contiene las propiedades de ambos objetos. Esta es una forma poderosa de combinar datos de diferentes fuentes.

Restricciones de genéricos

Como se mostró anteriormente, las restricciones te permiten limitar los tipos que se pueden usar con un parámetro de tipo genérico. Esto asegura que el código genérico pueda operar de forma segura sobre los tipos especificados.


interface Lengthwise {
  length: number;
}

function loggingIdentity<T extends Lengthwise>(arg: T): T {
  console.log(arg.length);
  return arg;
}

loggingIdentity([1, 2, 3]); // Salida: 3
loggingIdentity("hello"); // Salida: 5
// loggingIdentity(123); // Error: El argumento de tipo 'number' no es asignable al parámetro de tipo 'Lengthwise'.

La función loggingIdentity toma un argumento de tipo T que debe tener una propiedad length de tipo number. Esto asegura que la función pueda acceder de forma segura a la propiedad length.

Clases genéricas

Los genéricos también se pueden usar con clases:


class DataStorage<T> {
  private data: T[] = [];

  addItem(item: T) {
    this.data.push(item);
  }

  removeItem(item: T) {
    this.data = this.data.filter(d => d !== item);
  }

  getItems(): T[] {
    return [...this.data];
  }
}

const textStorage = new DataStorage<string>();
textStorage.addItem("apple");
textStorage.addItem("banana");
textStorage.removeItem("apple");
console.log(textStorage.getItems()); // Salida: [ 'banana' ]

const numberStorage = new DataStorage<number>();
numberStorage.addItem(1);
numberStorage.addItem(2);
numberStorage.removeItem(1);
console.log(numberStorage.getItems()); // Salida: [ 2 ]

La clase DataStorage puede almacenar datos de cualquier tipo T. Esto te permite crear estructuras de datos reutilizables que son seguras en cuanto a tipos.

Interfaces genéricas

Las interfaces genéricas son útiles para definir contratos que pueden funcionar con diferentes tipos. Por ejemplo:


interface Result<T, E> {
  success: boolean;
  data?: T;
  error?: E;
}

interface User {
  id: number;
  username: string;
  email: string;
}

interface ErrorMessage {
  code: number;
  message: string;
}

function fetchUser(id: number): Result<User, ErrorMessage> {
  if (id === 1) {
    return { success: true, data: { id: 1, username: "john.doe", email: "john.doe@example.com" } };
  } else {
    return { success: false, error: { code: 404, message: "Usuario no encontrado" } };
  }
}

const userResult = fetchUser(1);
if (userResult.success) {
  console.log(userResult.data.username);
} else {
  console.log(userResult.error.message);
}

La interfaz Result define una estructura genérica para representar el resultado de una operación. Puede contener datos de tipo T o un error de tipo E. Este es un patrón común para manejar operaciones asíncronas u operaciones que pueden fallar.

Tipos de utilidad y genéricos

TypeScript proporciona varios tipos de utilidad incorporados que funcionan bien con genéricos. Estos tipos de utilidad pueden ayudarte a transformar y manipular tipos de formas potentes.

Partial<T>

Partial<T> hace que todas las propiedades del tipo T sean opcionales:


interface Person {
  name: string;
  age: number;
}

type PartialPerson = Partial<Person>;

const partialPerson: PartialPerson = { name: "Alice" }; // Válido

Readonly<T>

Readonly<T> hace que todas las propiedades del tipo T sean de solo lectura:


interface Person {
  name: string;
  age: number;
}

type ReadonlyPerson = Readonly<Person>;

const readonlyPerson: ReadonlyPerson = { name: "Bob", age: 42 };
// readonlyPerson.age = 43; // Error: No se puede asignar a 'age' porque es una propiedad de solo lectura.

Pick<T, K>

Pick<T, K> selecciona un conjunto de propiedades K del tipo T:


interface Person {
  name: string;
  age: number;
  email: string;
}

type NameAndAge = Pick<Person, "name" | "age">;

const nameAndAge: NameAndAge = { name: "Charlie", age: 28 };

Omit<T, K>

Omit<T, K> elimina un conjunto de propiedades K del tipo T:


interface Person {
  name: string;
  age: number;
  email: string;
}

type PersonWithoutEmail = Omit<Person, "email">;

const personWithoutEmail: PersonWithoutEmail = { name: "David", age: 35 };

Record<K, T>

Record<K, T> crea un tipo con claves K y valores de tipo T:


type CountryCodes = "US" | "CA" | "UK" | "DE" | "FR" | "JP" | "CN" | "IN" | "BR" | "AU"; // Lista ampliada para el contexto global
type Currency = "USD" | "CAD" | "GBP" | "EUR" | "JPY" | "CNY" | "INR" | "BRL" | "AUD"; // Lista ampliada para el contexto global

type CurrencyMap = Record<CountryCodes, Currency>;

const currencyMap: CurrencyMap = {
  "US": "USD",
  "CA": "CAD",
  "UK": "GBP",
  "DE": "EUR",
  "FR": "EUR",
  "JP": "JPY",
  "CN": "CNY",
  "IN": "INR",
  "BR": "BRL",
  "AU": "AUD",
};

Tipos mapeados

Los tipos mapeados te permiten transformar tipos existentes iterando sobre sus propiedades. Esta es una forma potente de crear nuevos tipos basados en los existentes. Por ejemplo, puedes crear un tipo que haga que todas las propiedades de otro tipo sean de solo lectura:


interface Person {
  name: string;
  age: number;
}

type ReadonlyPerson = {
  readonly [K in keyof Person]: Person[K];
};

const readonlyPerson: ReadonlyPerson = { name: "Eve", age: 25 };
// readonlyPerson.age = 26; // Error: No se puede asignar a 'age' porque es una propiedad de solo lectura.

En este ejemplo, [K in keyof Person] itera sobre todas las claves de la interfaz Person, y Person[K] accede al tipo de cada propiedad. La palabra clave readonly hace que cada propiedad sea de solo lectura.

Tipos condicionales

Los tipos condicionales te permiten definir tipos basados en condiciones. Esta es una forma potente de crear tipos que se adaptan a diferentes escenarios.


type NonNullable<T> = T extends null | undefined ? never : T;

type MaybeString = string | null | undefined;
type StringType = NonNullable<MaybeString>; // string

function getValue<T>(value: T): NonNullable<T> {
  if (value == null) { // Maneja tanto null como undefined
    throw new Error("El valor no puede ser nulo o indefinido");
  }
  return value as NonNullable<T>;
}

try {
  const validValue = getValue("hello");
  console.log(validValue.toUpperCase()); // Salida: HELLO

  const invalidValue = getValue(null); // Esto lanzará un error
  console.log(invalidValue); // Esta línea no se alcanzará
} catch (error: any) {
  console.error(error.message); // Salida: El valor no puede ser nulo o indefinido
}

En este ejemplo, el tipo NonNullable<T> comprueba si T es null o undefined. Si lo es, devuelve never, lo que significa que el tipo no está permitido. De lo contrario, devuelve T. Esto te permite crear tipos que se garantiza que no son nulos.

Mejores prácticas para usar genéricos

Aquí hay algunas mejores prácticas a tener en cuenta al usar genéricos:

Ejemplos en un contexto global

Consideremos algunos ejemplos de cómo se pueden usar los genéricos en un contexto global:

Conversión de moneda


interface ConversionRate {
  rate: number;
  fromCurrency: string;
  toCurrency: string;
}

function convertCurrency<T extends ConversionRate>(amount: number, rate: T): number {
  return amount * rate.rate;
}

const usdToEurRate: ConversionRate = { rate: 0.85, fromCurrency: "USD", toCurrency: "EUR" };
const amountInUSD = 100;
const amountInEUR = convertCurrency(amountInUSD, usdToEurRate);
console.log(`${amountInUSD} USD equivalen a ${amountInEUR} EUR`); // Salida: 100 USD equivalen a 85 EUR

Formateo de fechas


interface DateFormatOptions {
  locale: string;
  options: Intl.DateTimeFormatOptions;
}

function formatDate<T extends DateFormatOptions>(date: Date, format: T): string {
  return date.toLocaleDateString(format.locale, format.options);
}

const currentDate = new Date();

const usDateFormat: DateFormatOptions = { locale: "en-US", options: { year: 'numeric', month: 'long', day: 'numeric' } };
const germanDateFormat: DateFormatOptions = { locale: "de-DE", options: { year: 'numeric', month: 'long', day: 'numeric' } };
const japaneseDateFormat: DateFormatOptions = { locale: "ja-JP", options: { year: 'numeric', month: 'long', day: 'numeric' } };

console.log("Fecha de EE. UU.: " + formatDate(currentDate, usDateFormat));
console.log("Fecha de Alemania: " + formatDate(currentDate, germanDateFormat));
console.log("Fecha de Japón: " + formatDate(currentDate, japaneseDateFormat));

Servicio de traducción


interface Translation {
  [key: string]: string; // Permite claves de idioma dinámicas
}

interface LanguageData<T extends Translation> {
  languageCode: string;
  translations: T;
}

const englishTranslations: Translation = {
  "hello": "Hello",
  "goodbye": "Goodbye",
  "welcome": "Welcome to our website!"
};

const spanishTranslations: Translation = {
  "hello": "Hola",
  "goodbye": "Adiós",
  "welcome": "¡Bienvenido a nuestro sitio web!"
};

const frenchTranslations: Translation = {
  "hello": "Bonjour",
  "goodbye": "Au revoir",
  "welcome": "Bienvenue sur notre site web !"
};


const languageData: LanguageData<typeof englishTranslations>[] = [
  {languageCode: "en", translations: englishTranslations },
  {languageCode: "es", translations: spanishTranslations },
  {languageCode: "fr", translations: frenchTranslations}
];

function translate<T extends Translation>(key: string, languageCode: string, languageData: LanguageData<T>[]): string {
  const lang = languageData.find(lang => lang.languageCode === languageCode);
  if (!lang) {
    return `Traducción para ${key} en ${languageCode} no encontrada.`;
  }
  return lang.translations[key] || `Traducción para ${key} no encontrada.`;
}

console.log(translate("hello", "en", languageData)); // Salida: Hello
console.log(translate("hello", "es", languageData)); // Salida: Hola
console.log(translate("welcome", "fr", languageData)); // Salida: Bienvenue sur notre site web !
console.log(translate("missingKey", "de", languageData)); // Salida: Traducción para missingKey en de no encontrada.

Conclusión

Los genéricos de TypeScript son una herramienta poderosa para escribir código reutilizable y seguro en cuanto a tipos que puede funcionar con tipos de datos complejos. Al comprender la sintaxis básica, las características avanzadas y las mejores prácticas de los genéricos, puedes mejorar significativamente la calidad y la mantenibilidad de tus aplicaciones TypeScript. Al desarrollar aplicaciones para una audiencia global, los genéricos pueden ayudarte a manejar diversos formatos de datos y convenciones culturales, asegurando una experiencia de usuario fluida para todos.